winbrew_app\operations\repair/
replay.rs

1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use tracing::warn;
6
7use crate::core::paths::install_root_from_package_dir;
8use crate::database;
9use crate::models::domains::command_resolution::ResolverResult;
10use crate::operations::install;
11use crate::operations::shims;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum JournalCommandResolutionStatus {
15    Unknown,
16    Fresh,
17    Stale {
18        committed_fingerprint: String,
19        current_fingerprint: String,
20    },
21}
22
23#[derive(Debug, Clone)]
24pub struct JournalReplayTarget {
25    pub journal_path: PathBuf,
26    pub committed: database::CommittedJournalPackage,
27    pub command_resolution_status: JournalCommandResolutionStatus,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct JournalReplaySummary {
32    pub total: usize,
33    pub fresh: usize,
34    pub stale: usize,
35    pub unknown: usize,
36}
37
38pub fn replay_committed_journals(journal_paths: &[PathBuf]) -> Result<usize> {
39    let targets = prepare_journal_replay_targets(journal_paths)?;
40    replay_prepared_journal_targets(&targets)
41}
42
43pub fn prepare_journal_replay_targets(
44    journal_paths: &[PathBuf],
45) -> Result<Vec<JournalReplayTarget>> {
46    let catalog_conn = match database::get_catalog_conn() {
47        Ok(conn) => Some(conn),
48        Err(err) => {
49            warn!(
50                error = %err,
51                "failed to open catalog database for repair command resolution comparison"
52            );
53            None
54        }
55    };
56    let mut targets = Vec::with_capacity(journal_paths.len());
57
58    for journal_path in journal_paths {
59        let committed = database::JournalReader::read_committed_package(journal_path)
60            .with_context(|| {
61                format!(
62                    "failed to parse committed journal at {}",
63                    journal_path.display()
64                )
65            })?;
66
67        if committed.command_resolution.is_none() {
68            bail!(
69                "committed journal at {} is missing command resolution metadata",
70                journal_path.display()
71            );
72        }
73
74        let current_resolution = catalog_conn
75            .as_ref()
76            .and_then(|conn| current_command_resolution(conn, &committed.package.name));
77
78        let command_resolution_status = classify_journal_command_resolution_status(
79            committed.command_resolution.as_ref(),
80            current_resolution,
81        );
82
83        if let JournalCommandResolutionStatus::Stale {
84            committed_fingerprint,
85            current_fingerprint,
86        } = &command_resolution_status
87        {
88            warn!(
89                package = committed.package.name.as_str(),
90                committed_fingerprint = committed_fingerprint.as_str(),
91                current_fingerprint = current_fingerprint.as_str(),
92                "committed journal command resolution fingerprint differs from current catalog metadata"
93            );
94        }
95
96        targets.push(JournalReplayTarget {
97            journal_path: journal_path.clone(),
98            committed,
99            command_resolution_status,
100        });
101    }
102
103    Ok(targets)
104}
105
106pub fn replay_prepared_journal_targets(targets: &[JournalReplayTarget]) -> Result<usize> {
107    let mut conn = database::get_conn()?;
108    let mut replayed = 0usize;
109
110    for target in targets {
111        let committed = &target.committed;
112        let previous_commands = database::list_commands_for_package(&conn, &committed.package.name)
113            .unwrap_or_else(|err| {
114                warn!(
115                    package = committed.package.name.as_str(),
116                    error = %err,
117                    "failed to read existing package commands before replay"
118                );
119                Vec::new()
120            });
121        database::replay_committed_journal(&mut conn, committed).with_context(|| {
122            format!(
123                "failed to replay committed journal at {}",
124                target.journal_path.display()
125            )
126        })?;
127        let shims_root =
128            install_root_from_package_dir(Path::new(&committed.package.install_dir)).join("shims");
129        let desired_commands = journal_commands(committed);
130        let targets = journal_shim_targets(committed);
131
132        if let Err(err) = shims::publish_shims_for_install_dir(
133            &shims_root,
134            Path::new(&committed.package.install_dir),
135            desired_commands,
136            &targets,
137        ) {
138            warn!(
139                package = committed.package.name.as_str(),
140                error = %err,
141                "failed to publish package shims during repair replay"
142            );
143        } else {
144            let desired_commands = desired_commands.iter().cloned().collect::<BTreeSet<_>>();
145            let stale_commands = previous_commands
146                .into_iter()
147                .filter(|command| !desired_commands.contains(command))
148                .collect::<Vec<_>>();
149
150            if !stale_commands.is_empty()
151                && let Err(err) = shims::remove_shim_files(&shims_root, &stale_commands)
152            {
153                warn!(
154                    package = committed.package.name.as_str(),
155                    error = %err,
156                    "failed to remove stale package shims during repair replay"
157                );
158            }
159        }
160        replayed += 1;
161    }
162
163    Ok(replayed)
164}
165
166pub fn summarize_journal_replay_targets(targets: &[JournalReplayTarget]) -> JournalReplaySummary {
167    let mut summary = JournalReplaySummary {
168        total: targets.len(),
169        ..JournalReplaySummary::default()
170    };
171
172    for target in targets {
173        match target.command_resolution_status {
174            JournalCommandResolutionStatus::Fresh => summary.fresh += 1,
175            JournalCommandResolutionStatus::Stale { .. } => summary.stale += 1,
176            JournalCommandResolutionStatus::Unknown => summary.unknown += 1,
177        }
178    }
179
180    summary
181}
182
183fn journal_commands(committed: &database::CommittedJournalPackage) -> &[String] {
184    match committed.command_resolution.as_ref() {
185        Some(ResolverResult::Resolved { commands, .. }) => commands.as_slice(),
186        Some(ResolverResult::Unresolved { .. }) | None => &[],
187    }
188}
189
190fn journal_shim_targets(committed: &database::CommittedJournalPackage) -> Vec<shims::ShimTarget> {
191    if let Some(bindings) = committed.bin_bindings.as_deref() {
192        return shims::shim_targets_from_journal_bindings(bindings);
193    }
194
195    let empty_paths: &[String] = &[];
196    shims::legacy_shim_targets(committed.bin.as_deref().unwrap_or(empty_paths))
197}
198
199fn current_command_resolution(
200    catalog_conn: &database::DbConnection,
201    package_id: &str,
202) -> Option<ResolverResult> {
203    let package = match database::get_package_by_id(catalog_conn, package_id) {
204        Ok(Some(package)) => package,
205        Ok(None) => return None,
206        Err(err) => {
207            warn!(
208                package = package_id,
209                error = %err,
210                "failed to read catalog package for repair command resolution comparison"
211            );
212            return None;
213        }
214    };
215
216    let installers = match database::get_installers(catalog_conn, package.id.as_str()) {
217        Ok(installers) => installers,
218        Err(err) => {
219            warn!(
220                package = package_id,
221                error = %err,
222                "failed to read catalog installers for repair command resolution comparison"
223            );
224            return None;
225        }
226    };
227
228    let selection_context = crate::catalog::SelectionContext::new(
229        crate::windows::host::host_profile(),
230        crate::windows::host::is_elevated(),
231    );
232    let installer = match install::types::select_installer(&installers, selection_context) {
233        Ok(installer) => installer,
234        Err(err) => {
235            warn!(
236                package = package_id,
237                error = %err,
238                "failed to select catalog installer for repair command resolution comparison"
239            );
240            return None;
241        }
242    };
243
244    match crate::models::domains::command_resolution::resolve_command_exposure(&package, &installer)
245    {
246        Ok(resolution) => Some(resolution),
247        Err(err) => {
248            warn!(
249                package = package_id,
250                error = %err,
251                "failed to resolve current command exposure for repair comparison"
252            );
253            None
254        }
255    }
256}
257
258pub(crate) fn classify_journal_command_resolution_status(
259    committed: Option<&ResolverResult>,
260    current: Option<ResolverResult>,
261) -> JournalCommandResolutionStatus {
262    let Some(committed_resolution) = committed else {
263        return JournalCommandResolutionStatus::Unknown;
264    };
265
266    let ResolverResult::Resolved {
267        catalog_fingerprint: committed_fingerprint,
268        ..
269    } = committed_resolution
270    else {
271        return JournalCommandResolutionStatus::Unknown;
272    };
273
274    let Some(current_resolution) = current.as_ref() else {
275        return JournalCommandResolutionStatus::Unknown;
276    };
277
278    let ResolverResult::Resolved {
279        catalog_fingerprint: current_fingerprint,
280        ..
281    } = current_resolution
282    else {
283        return JournalCommandResolutionStatus::Unknown;
284    };
285
286    if !command_resolution_is_stale(committed_resolution, current_resolution) {
287        JournalCommandResolutionStatus::Fresh
288    } else {
289        JournalCommandResolutionStatus::Stale {
290            committed_fingerprint: committed_fingerprint.clone(),
291            current_fingerprint: current_fingerprint.clone(),
292        }
293    }
294}
295
296pub(crate) fn command_resolution_is_stale(
297    committed: &ResolverResult,
298    current: &ResolverResult,
299) -> bool {
300    match (committed, current) {
301        (
302            ResolverResult::Resolved {
303                catalog_fingerprint: committed_fingerprint,
304                ..
305            },
306            ResolverResult::Resolved {
307                catalog_fingerprint: current_fingerprint,
308                ..
309            },
310        ) => committed_fingerprint != current_fingerprint,
311        _ => false,
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::{
318        JournalCommandResolutionStatus, JournalReplayTarget,
319        classify_journal_command_resolution_status, command_resolution_is_stale,
320        summarize_journal_replay_targets,
321    };
322    use crate::models::domains::command_resolution::{
323        CommandSource, Confidence, ResolverResult, VersionScope,
324    };
325    use crate::models::domains::install::{EngineKind, InstallerType};
326    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
327    use crate::models::domains::shared::DeploymentKind;
328    use std::path::PathBuf;
329
330    fn test_committed_package() -> crate::database::CommittedJournalPackage {
331        crate::database::CommittedJournalPackage {
332            journal_path: PathBuf::from("C:/winbrew/pkgdb/Contoso.App/journal.jsonl"),
333            entries: Vec::new(),
334            package: InstalledPackage {
335                name: "Contoso.App".to_string(),
336                version: "1.0.0".to_string(),
337                kind: InstallerType::Portable,
338                deployment_kind: DeploymentKind::Portable,
339                engine_kind: EngineKind::Portable,
340                engine_metadata: None,
341                install_dir: "C:/winbrew/packages/Contoso.App".to_string(),
342                dependencies: Vec::new(),
343                status: PackageStatus::Ok,
344                installed_at: "2026-04-12T00:00:00Z".to_string(),
345            },
346            commands: Some(vec!["contoso".to_string()]),
347            bin: Some(vec!["bin/tool.exe".to_string()]),
348            bin_bindings: None,
349            env_add_path: Vec::new(),
350            command_resolution: Some(ResolverResult::Resolved {
351                commands: vec!["contoso".to_string()],
352                confidence: Confidence::High,
353                sources: vec![CommandSource::PackageLevel],
354                version_scope: VersionScope::Specific("1.0.0".to_string()),
355                catalog_fingerprint: "sha256:deadbeef".to_string(),
356            }),
357        }
358    }
359
360    fn test_journal_target(status: JournalCommandResolutionStatus) -> JournalReplayTarget {
361        JournalReplayTarget {
362            journal_path: PathBuf::from("C:/winbrew/pkgdb/Contoso.App/journal.jsonl"),
363            committed: test_committed_package(),
364            command_resolution_status: status,
365        }
366    }
367
368    #[test]
369    fn command_resolution_is_stale_when_fingerprints_differ() {
370        let committed = ResolverResult::Resolved {
371            commands: vec!["contoso".to_string()],
372            confidence: Confidence::High,
373            sources: vec![CommandSource::PackageLevel],
374            version_scope: VersionScope::Specific("1.0.0".to_string()),
375            catalog_fingerprint: "sha256:deadbeef".to_string(),
376        };
377        let current = ResolverResult::Resolved {
378            commands: vec!["contoso".to_string()],
379            confidence: Confidence::High,
380            sources: vec![CommandSource::PackageLevel],
381            version_scope: VersionScope::Specific("1.0.0".to_string()),
382            catalog_fingerprint: "sha256:cafebabe".to_string(),
383        };
384
385        assert!(command_resolution_is_stale(&committed, &current));
386    }
387
388    #[test]
389    fn classify_journal_command_resolution_status_tracks_fresh_stale_and_unknown_states() {
390        let committed = ResolverResult::Resolved {
391            commands: vec!["contoso".to_string()],
392            confidence: Confidence::High,
393            sources: vec![CommandSource::PackageLevel],
394            version_scope: VersionScope::Specific("1.0.0".to_string()),
395            catalog_fingerprint: "sha256:deadbeef".to_string(),
396        };
397        let current = ResolverResult::Resolved {
398            commands: vec!["contoso".to_string()],
399            confidence: Confidence::High,
400            sources: vec![CommandSource::PackageLevel],
401            version_scope: VersionScope::Specific("1.0.0".to_string()),
402            catalog_fingerprint: "sha256:deadbeef".to_string(),
403        };
404        let stale = ResolverResult::Resolved {
405            commands: vec!["contoso".to_string()],
406            confidence: Confidence::High,
407            sources: vec![CommandSource::PackageLevel],
408            version_scope: VersionScope::Specific("1.0.0".to_string()),
409            catalog_fingerprint: "sha256:cafebabe".to_string(),
410        };
411
412        assert!(matches!(
413            classify_journal_command_resolution_status(Some(&committed), Some(current)),
414            JournalCommandResolutionStatus::Fresh
415        ));
416        assert!(matches!(
417            classify_journal_command_resolution_status(Some(&committed), Some(stale)),
418            JournalCommandResolutionStatus::Stale {
419                committed_fingerprint,
420                current_fingerprint,
421            } if committed_fingerprint == "sha256:deadbeef" && current_fingerprint == "sha256:cafebabe"
422        ));
423        assert!(matches!(
424            classify_journal_command_resolution_status(None, None),
425            JournalCommandResolutionStatus::Unknown
426        ));
427    }
428
429    #[test]
430    fn summarize_journal_replay_targets_counts_statuses() {
431        let summary = summarize_journal_replay_targets(&[
432            test_journal_target(JournalCommandResolutionStatus::Fresh),
433            test_journal_target(JournalCommandResolutionStatus::Stale {
434                committed_fingerprint: "sha256:deadbeef".to_string(),
435                current_fingerprint: "sha256:cafebabe".to_string(),
436            }),
437            test_journal_target(JournalCommandResolutionStatus::Unknown),
438        ]);
439
440        assert_eq!(summary.total, 3);
441        assert_eq!(summary.fresh, 1);
442        assert_eq!(summary.stale, 1);
443        assert_eq!(summary.unknown, 1);
444    }
445}